package org.codefilarete.stalactite.sql.ddl.structure;

import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import javax.annotation.Nullable;

import org.codefilarete.stalactite.query.api.Fromable;
import org.codefilarete.stalactite.query.api.Selectable;
import org.codefilarete.stalactite.sql.ddl.FixedPoint;
import org.codefilarete.stalactite.sql.ddl.Length;
import org.codefilarete.stalactite.sql.ddl.Size;
import org.codefilarete.stalactite.sql.ddl.structure.Database.Schema;
import org.codefilarete.tool.Reflections;
import org.codefilarete.tool.StringAppender;
import org.codefilarete.tool.collection.CaseInsensitiveMap;
import org.codefilarete.tool.collection.Iterables;
import org.codefilarete.tool.collection.KeepOrderSet;
import org.codefilarete.tool.function.Predicates;

import static org.codefilarete.tool.Nullable.nullable;

/**
 * Representation of a database Table, not exhaustive but sufficient for our need.
 *
 * @author Guillaume Mary
 */
public class Table<SELF extends Table<SELF>> implements Fromable {

	private static final Comparator<Table<?>> COMPARATOR_ON_NAME = Comparator.comparing(Table::getName, String.CASE_INSENSITIVE_ORDER);
	private static final Comparator<Table<?>> COMPARATOR_ON_SCHEMA = Comparator.comparing(Table::getSchema, Comparator.nullsFirst(Comparator.comparing(Schema::getName, String.CASE_INSENSITIVE_ORDER)));

	/**
	 * Comparator on schema and name
	 */
	// implementation note: we compose it from the 2 above comparators due to generics issue that is resolve by decomposing the one line it 2 variables 
	public static final Comparator<Table<?>> COMPARATOR_ON_SCHEMA_AND_NAME = COMPARATOR_ON_SCHEMA.thenComparing(COMPARATOR_ON_NAME);
	
	@Nullable
	private final Schema schema;
	
	private final String name;
	
	private final String absoluteName;
	
	private final KeepOrderSet<Column<SELF, Object>> columns = new KeepOrderSet<>();
	
	private PrimaryKey<SELF, ?> primaryKey;
	
	private final Set<Index<SELF>> indexes = new HashSet<>();
	
	private final Set<UniqueConstraint<SELF>> uniqueConstraints = new HashSet<>();
	
	private final Set<ForeignKey<SELF, ? extends Table<?>, ?>> foreignKeys = new HashSet<>();
	
	/**
	 * Index {@link Column}s by their name to ease some checks and lookup.
	 * The index is case-insensitive to avoid bothering issues, especially when the Fluent Mapping API refers to a {@link Column} but it can't be
	 * found due to a discrepancy between schema definition (uppercased for example) and property name (CamelCase). Not finding the column will create
	 * a duplicate, which then will raise an exception at schema generation or any SQL operation.
	 * @see #upsertColumn(Column)
	 */
	private final Map<String, Column<SELF, ?>> columnsPerName = new CaseInsensitiveMap<>();
	
	public Table(String name) {
		this(null, name);
	}
	
	public Table(@Nullable Schema schema, String name) {
		this.schema = schema;
		if (this.schema != null) {
			this.schema.addTable(this);
		}
		this.name = name;
		this.absoluteName = nullable(schema).map(Schema::getName).map(s -> s + "." + name).getOr(name);
	}
	
	@Nullable
	public Schema getSchema() {
		return schema;
	}
	
	public String getName() {
		return name;
	}
	
	public String getAbsoluteName() {
		return absoluteName;
	}
	
	/**
	 * Gives columns of this table as an unmodifiable set.
	 * 
	 * @return an unmodifiable Set&lt;Column&gt;
	 */
	public Set<Column<SELF, ?>> getColumns() {
		return Collections.unmodifiableSet(columns);
	}
	
	@Override
	public Map<Selectable<?>, String> getAliases() {
		Map<Column<SELF, ?>, String> result = new HashMap<>();
		columnsPerName.forEach((key, value) -> result.put(value, key));
		return Collections.unmodifiableMap(result);
	}
	
	public Set<Column<SELF, Object>> getColumnsNoPrimaryKey() {
		LinkedHashSet<Column<SELF, Object>> result = new LinkedHashSet<>(this.columns);
		result.removeAll(nullable(getPrimaryKey()).map(PrimaryKey::getColumns).getOr(new KeepOrderSet<>()));
		return result;
	}
	
	/**
	 * Adds a column to this table.
	 * May do nothing if a column already exists with same name and type.
	 * Will throw an exception if a column with same name but with different type already exists.
	 * 
	 * @param name column name
	 * @param javaType column type
	 * @param <O> column type
	 * @return the created column or the existing one
	 */
	public <O> Column<SELF, O> addColumn(String name, Class<O> javaType) {
		return upsertColumn(new Column<>((SELF) this, name, javaType));
	}
	
	/**
	 * Adds a column to this table.
	 * May do nothing if a column already exists with same name, size and type.
	 * Will throw an exception if a column with same name but with different type or size already exists.
	 *
	 * @param name column name
	 * @param javaType column type
	 * @param size column type size, null if type doesn't require any size ({@link #addColumn(String, Class)} should be prefered in this case)
	 *             but let as such for column copy use case
	 * @param <O> column type
	 * @return the created column or the existing one
	 */
	public <O> Column<SELF, O> addColumn(String name, Class<O> javaType, @Nullable Size size) {
		return upsertColumn(new Column<>((SELF) this, name, javaType, size));
	}
	
	public <O> Column<SELF, O> addColumn(String name, Class<O> javaType, @Nullable Size size, @Nullable Boolean nullable) {
		return upsertColumn(new Column<>((SELF) this, name, javaType, size, nullable));
	}
	
	/**
	 * Adds with presence assertion (add + assert = addert, poor naming)
	 * 
	 * @param newColumn the column to be added
	 * @param <O> column type
	 * @return given column
	 */
	private <O> Column<SELF, O> upsertColumn(Column<SELF, O> newColumn) {
		Column<SELF, O> existingColumn = getColumn(newColumn.getName());
		if (existingColumn != null) {
			if (existingColumn.getJavaType().equals(newColumn.getJavaType())
					&& applySizeIfPossible(existingColumn, newColumn)
					&& applyNullableIfPossible(existingColumn, newColumn)) {
				return existingColumn;
			} else {
				throw new IllegalArgumentException("Trying to add column '" + newColumn.getName() + "' to '" + this.getAbsoluteName()
						+ "' but it already exists with a different type : "
						+ typeToString(existingColumn) + " vs " + typeToString(newColumn)
						+ ", nullable " + nullableToString(existingColumn) + " vs " + nullableToString(newColumn));
			}
		} else {
			columns.add((Column<SELF, Object>) newColumn);
			columnsPerName.put(newColumn.getName(), newColumn);
			return newColumn;
		}
	}
	
	/**
	 * Applies <code>newColumn</code> size to <code>existingColumn</code> if the latter hasn't one.
	 * Returns false if both columns have a size that are not equal.
	 * Does nothing if <code>existingColumn</code> has a size and <code>newColumn</code> hasn't.
	 * Can be seen has: the one that has a size wins, if both have one, then the conflict must be raised.
	 *
	 * @param existingColumn the column in this {@link Table} instance
	 * @param newColumn the new column that user wants to add
	 * @return false if both columns have a size that are not equal, else, true
	 * @param <O> columns Java type
	 */
	private <O> boolean applySizeIfPossible(Column<SELF, O> existingColumn, Column<SELF, O> newColumn) {
		Size existingSize = existingColumn.getSize();
		Size newSize = newColumn.getSize();
		if (existingSize == null) {
			if (newSize != null) {
				existingColumn.setSize(newSize);
			}
			return true;
		} else {
			if (newSize != null) {
				if (existingSize instanceof Length && newSize instanceof Length) {
					return ((Length) existingSize).getValue() == ((Length) newSize).getValue();
				} else if (existingSize instanceof FixedPoint && newSize instanceof FixedPoint) {
					return ((FixedPoint) existingSize).getPrecision() == ((FixedPoint) newSize).getPrecision()
							&& Predicates.equalOrNull(((FixedPoint) existingSize).getScale(), ((FixedPoint) newSize).getPrecision());
				} else {
					return false;
				}
			} else {
				return true;
			}
		}
	}
	
	private <O> boolean applyNullableIfPossible(Column<SELF, O> existingColumn, Column<SELF, O> newColumn) {
		Boolean existingNullable = existingColumn.isNullable();
		Boolean newNullable = newColumn.isNullable();
		if (existingNullable == null) {
			if (newNullable != null) {
				existingColumn.setNullable(newNullable);
			}
			return true;
		} else {
			if (newNullable != null) {
				return existingNullable == newNullable;
			} else {
				return true;
			}
		}
	}
	
	@Override
	public Map<String, Column<SELF, ?>> mapColumnsOnName() {
		return new HashMap<>(columnsPerName);
	}
	
	public <C> Column<SELF, C> getColumn(String columnName) {
		return (Column<SELF, C>) columnsPerName.get(columnName);
	}
	
	/**
	 * Finds a column by its name (strict equality).
	 * 
	 * @param columnName an expected matching column name
	 * @return null if not found
	 */
	@Override
	public Column<SELF, ?> findColumn(String columnName) {
		return getColumn(columnName);
	}
	
	/**
	 * Returns the {@link PrimaryKey} of this table if any {@link Column} was marked as primary key, else will return null.
	 * Lazily initializes an attribute, hence multiple calls to this method may return the very first attempt even if another {@link Column} was
	 * marked as primary key between calls.
	 * 
	 * @return the {@link PrimaryKey} of this table if any {@link Column} was marked as primary key, else null
	 */
	public <ID> PrimaryKey<SELF, ID> getPrimaryKey() {
		if (primaryKey == null) {
			Set<Column<SELF, Object>> pkColumns = Iterables.collect(columns, Column::isPrimaryKey, Function.identity(), LinkedHashSet::new);
			primaryKey = pkColumns.isEmpty() ? null : new PrimaryKey<>(pkColumns);
		}
		return (PrimaryKey<SELF, ID>) primaryKey;
	}
	
	public Set<Index<SELF>> getIndexes() {
		return Collections.unmodifiableSet(indexes);
	}
	
	public Index<SELF> addIndex(String name, Column<SELF, ?> column, Column<SELF, ?> ... columns) {
		Index<SELF> newIndex = new Index<>(name, column, columns);
		this.indexes.add(newIndex);
		return newIndex;
	}
	
	public Index<SELF> addIndex(String name, Iterable<? extends Column<SELF, ?>> columns) {
		Index<SELF> newIndex = new Index<>(name, columns);
		this.indexes.add(newIndex);
		return newIndex;
	}
	
	public Set<ForeignKey<SELF, ?, ?>> getForeignKeys() {
		return Collections.unmodifiableSet(foreignKeys);
	}
	
	public <T extends Table<T>, I> ForeignKey<SELF, T, I> addForeignKey(BiFunction<Column<SELF, I>, Column<T, I>, String> namingFunction,
																	 Column<SELF, I> column, Column<T, I> targetColumn) {
		return this.addForeignKey(namingFunction.apply(column, targetColumn), column, targetColumn);
	}
	
	/**
	 * Adds a foreign key to this table.
	 * May do nothing if a foreign key already exists with same name and columns.
	 * Will throw an exception if a foreign key with same name but with different columns already exists.
	 *
	 * @param name column name
	 * @param column source column
	 * @param targetColumn referenced column
	 * @param <T> referenced table type
	 * @return the created foreign key or the existing one
	 */
	public <T extends Table<T>, I> ForeignKey<SELF, T, I> addForeignKey(String name, Column<SELF, I> column, Column<T, I> targetColumn) {
		return addssertForeignKey(new ForeignKey<>(name, column, targetColumn));
	}
	
	/**
	 * Adds a foreign key to this table.
	 * May do nothing if a foreign key already exists with same name and columns.
	 * Will throw an exception if a foreign key with same name but with different columns already exists.
	 *
	 * @param name column name
	 * @param columns source columns
	 * @param targetColumns referenced columns
	 * @param <T> referenced table type
	 * @return the created foreign key or the existing one
	 */
	public <T extends Table<T>, I> ForeignKey<SELF, T, I> addForeignKey(String name, List<? extends Column<SELF, Object>> columns, List<? extends Column<T, Object>> targetColumns) {
		return addssertForeignKey(new ForeignKey<>(name, new KeepOrderSet<>(columns), new KeepOrderSet<>(targetColumns)));
	}
	
	public <T extends Table<T>, I> ForeignKey<SELF, T, I> addForeignKey(String name, Key<SELF, I> columns, Key<T, I> targetColumns) {
		ForeignKey<SELF, T, I> foreignKey = new ForeignKey<>(
				name,
				columns.getColumns(),
				targetColumns.getColumns());
		return addssertForeignKey(foreignKey);
	}
	
	public <T extends Table<T>, I, K1 extends Key<SELF, I>, K2 extends Key<T, I>> ForeignKey<SELF, T, I> addForeignKey(
			BiFunction<K1, K2, String> namingFunction,
			K1 sourceColumns,
			K2 targetColumns) {
		ForeignKey<SELF, T, I> foreignKey = new ForeignKey<>(
				namingFunction.apply(sourceColumns, targetColumns),
				sourceColumns.getColumns(),
				targetColumns.getColumns());
		return addssertForeignKey(foreignKey);
	}
	
	/**
	 * Adds with presence assertion (add + assert = addssert, poor naming)
	 *
	 * @param foreignKey the column to be added
	 * @param <T> target table type
	 * @return given column
	 */
	private <T extends Table<T>, I> ForeignKey<SELF, T, I> addssertForeignKey(ForeignKey<SELF, T, I> foreignKey) {
		ForeignKey<SELF, T, I> existingForeignKey = assertDoesNotExistOnSameName(foreignKey);
		if (existingForeignKey == null) {
			assertDoesNotExistOnSameColumns(foreignKey);
			this.foreignKeys.add(foreignKey);
			return foreignKey;
		} else {
			return existingForeignKey;
		}
	}
	
	private <T extends Table<T>, I> ForeignKey<SELF, T, I> assertDoesNotExistOnSameName(ForeignKey<SELF, T, I> foreignKey) {
		ForeignKey<SELF, T, I> existingForeignKey = (ForeignKey<SELF, T, I>) Iterables.find(this.foreignKeys, fk -> fk.getName().equals(foreignKey.getName()));
		if (existingForeignKey != null
				&& (!existingForeignKey.getColumns().equals(foreignKey.getColumns())
				|| !existingForeignKey.getTargetColumns().equals(foreignKey.getTargetColumns()))
		) {
			throw new IllegalArgumentException("Trying to add a foreign key with same name than another with different columns : "
					+ foreignKey.getName() + " " + toString(existingForeignKey) + " vs " + toString(foreignKey));
		}
		return existingForeignKey;
	}
	
	private <T extends Table<T>, I> ForeignKey<SELF, T, I> assertDoesNotExistOnSameColumns(ForeignKey<SELF, T, I> foreignKey) {
		ForeignKey<SELF, T, I> existingForeignKeyWithSameColumns = (ForeignKey<SELF, T, I>) Iterables.find(this.foreignKeys, fk -> fk.getColumns().equals(foreignKey.getColumns()));
		if (existingForeignKeyWithSameColumns != null
				&& !existingForeignKeyWithSameColumns.getTargetColumns().equals(foreignKey.getTargetColumns())) {
			throw new IllegalArgumentException("A foreign key with same source columns but different referenced columns already exist : "
					+ "'" + existingForeignKeyWithSameColumns.getName() + "' <" + toString(existingForeignKeyWithSameColumns) + ">"
					+ " vs wanted new one '" + foreignKey.getName() + "' <" + toString(foreignKey) + ">");
		}
		return existingForeignKeyWithSameColumns;
	}
	
	public Set<UniqueConstraint<SELF>> getUniqueConstraints() {
		return uniqueConstraints;
	}
	
	public UniqueConstraint<SELF> addUniqueConstraint(String name, Column<SELF, ?> column, Column<SELF, ?> ... columns) {
		UniqueConstraint<SELF> newUniqueConstraint = new UniqueConstraint<>(name, column, columns);
		this.uniqueConstraints.add(newUniqueConstraint);
		return newUniqueConstraint;
	}
	
	private static String typeToString(Column column) {
		Size size = column.getSize();
		String sizeAsString = "";
		if (size instanceof Length) {
			sizeAsString = String.valueOf(((Length) size).getValue());
		} else if (size instanceof FixedPoint) {
			Integer scale = ((FixedPoint) size).getScale();
			String scaleAsString = scale == null ? "" : (", " + scale);
			sizeAsString = ((FixedPoint) size).getPrecision() + scaleAsString;
		}
		return Reflections.toString(column.getJavaType())
				+ (size != null ? "(" + sizeAsString + ")" : "");
	}
	
	private static String nullableToString(Column column) {
		return "" + column.isNullable();
	}
	
	private static String toString(ForeignKey column) {
		StringAppender result = new StringAppender() {
			@Override
			public StringAppender cat(Object s) {
				if (s instanceof Column) {
					return super.cat(((Column) s).getAbsoluteName());
				} else {
					return super.cat(s);
				}
			}
		};
		result
				.ccat(column.getColumns(), ", ")
				.cat(" -> ")
				.ccat(column.getTargetColumns(), ", ");
		return result.toString();
	}
}
